1|自定义渲染管线

loading

1.1 自定义渲染管线

自从Unity 2018引入了SRP(可编程渲染管线),它的高性能和高度定制化注定了会逐渐取代内置渲染管线(Built-in Render Pipeline)。我们可以编写C#代码去控制每一帧的渲染,并且可以进行断点调试。通过调用Unity开放的C#接口从而调用更底层的渲染接口来实现画面渲染,虽然没有做到完全的自由定制化,但它的各种优点是内置渲染管线无可比拟的。Unity 2019版本已经把SRP从Preview版转为正式版,为了便于区分,正式版通常叫做CRP(Custom Render Pipeline,自定义渲染管线),不过大多数人更习惯于叫它SRP,目前已经有很多知名游戏公司的在Unity项目中使用了SRP。通过本系列教程,我们将从零开始,搭建一套涵盖最基本功能的渲染管线,来学习其背后的技术原理。

本系列教程参考了著名的Jasper Flick先生的《Custom SRP》系列文章,有很多内容是直接从原英文教程中翻译过来的,大家也可以去他的网站上学习更多的渲染技术。在知乎、CSDN等平台上也有很多这个系列文章的翻译版本。

本系列教程的基本讲述结构以及代码模块与原作者的教程基本无差,因为他的教学模式非常友好,由浅入深,从易到难,一步步地实现一些功能模块。我也将延续他的这种教学模式,并对原教程中的很多技术知识点作出更详细地讲解说明,因为只有明白其原理和实现技术,我们才能更容易地理解后面的内容和代码的实现。原教程更适合有一定图形编程基础或者对内置渲染管线有一定研究的开发者,在学习的过程中会发现,原教程中对一些技术点只是一概而过,对于初学者来说不太友好。课程讲解中对一些技术点的讲解也参考和应用了一些很不错的书籍和文章中的内容,例如冯乐乐的《Unity Shader入门精要》、熊新科的《Unity3D 内建着色器源码剖析》等等。

若想了解更多SRP或是URP和HDRP的底层原理和应用,可以在UWA学堂学习雨松老师的新作《URP从原理到应用》系列图文教程,分为基础进阶两篇。他对这一块讲解的比较详细和深入,本系列教程则以实战为主。

最后,本系列教程的内容后续还会更新,包括新的技术内容,以及优化部分小节的内容。另外,在每个小节的最后还会提供相关章节的Demo工程和源码供大家学习和实践。

1.1.1 前置工作

本系列教程使用的Unity版本为Unity 2019.4.4f1,在创建渲染管线之前,我们先进行两个前置工作:

1. 首先通过Player Settings将Color Space由Gamma空间切换为Linear线性空间。

2. 通过Windows->PackageManager,搜索Core RP Library并下载该包。SRP、URP和HDRP都是依据该包进行功能拓展的,它是Unity开放出来供我们调用的C#接口,通过它调用更底层的C++提供的渲染接口。该包中还含有一些基本功能的着色器文件,其中的方法可以直接供我们调用,而不是实现任何功能都需要自己造轮子,省了一部分工作量。

loading

1.1.2 新建渲染管线资产

现在我们可以开始步入正题了,首先我们创建CustomRP文件夹,用来存放各类脚本、管线资产和着色器代码。之后创建Runtime子文件夹,存放运行脚本并新建脚本CustomRenderPineAsset.cs。

loading

该类继承自RenderPipelineAsset,是在UnityEngine.Rendering命名空间下定义的,其实所有C++提供的渲染接口都暴露在该命名空间下面。接下来我们需要重写CreatePipeline抽象方法,该方法返回一个RenderPipeline实例,我们先什么都不做,返回null。然后,在该类上面添加一个标签用来创建管线资产。

using UnityEngine;
using UnityEngine.Rendering;
//该标签会让你在Project下右键->Create菜单中添加一个新的子菜单
[CreateAssetMenu(menuName ="Rendering/CreateCustomRenderPipeline")]
public class CustomRenderPineAsset : RenderPipelineAsset
{
//重写抽象方法,需要返回一个RenderPipeline实例对象
protected override RenderPipeline CreatePipeline()
{
return null;
}
}

新建Assets子文件夹用于存储各类资产,并将新建渲染管线资产命名为CustomRP。接下来通过菜单Editor->Project Settings->Graphics,把创建的渲染管线资产拖入Scriptable Render Pipeline Settings中,这时你会发现Game视图变成黑色了。因为我们替换了默认的渲染管线,而新的管线还什么都没做,返回的是null,所以也就不显示任何内容了。

loading

这时我们打开Frame Debugger,发现确实没有绘制任何内容。

loading

1.1.3 创建渲染管线实例

在Runtime子文件夹中新建CustomRenderPipeline脚本,继承RenderPipeline,并实现抽象方法Render,目前我们还是什么都不做。

using UnityEngine;
using UnityEngine.Rendering;

public class CustomRenderPipeline : RenderPipeline
{
protected override void Render(ScriptableRenderContext context, Camera[] cameras)
{

}
}

接下来回到CustomRenderPineAsset脚本,我们在CreatePipeline方法中新建一个CustomRenderPipeline实例并返回。

protected override RenderPipeline CreatePipeline()
{
return new CustomRenderPipeline();
}


1.2 正式渲染

Unity每一帧都会调用CustomRenderPipeline实例的Render()方法进行画面渲染,该方法是SRP的入口,进行渲染时底层接口会调用它并传递两个参数,一个是ScriptableRenderContext对象,一个是Camera[]对象。

ScriptableRenderContext是SRP用于渲染的最底层接口之一,还有一个接口叫做CommandBuffer。我们通过这两个接口封装的各种方法来实现基本的渲染绘制,虽然这些方法的实现都是由C++在更底层实现的,但是我们只需要进行调用即可。

Camera[]是一个相机对象的数组,存储了参与这一帧渲染的所有相机对象。

1.2.1 相机渲染

虽然我们可以在CustomRenderPipeline中渲染所有的相机,但由于每个相机的渲染都是独立的,不如创建一个相机管理类去进行每个相机单独的渲染,而且后续功能会越来越多,这样做能够让代码更具有可读性且易管理。

我们在Runtime子文件夹新建一个CameraRenderer脚本用来进行单个相机单独渲染。定义一个相机的Render方法,用来绘制在相机视野内的所有物体。我们首先对传递来的渲染接口ScriptableRenderContext和当前相机Camera的对象进行存储追踪。

using UnityEngine;
using UnityEngine.Rendering;

public class CameraRenderer
{

ScriptableRenderContext context;

Camera camera;

public void Render (ScriptableRenderContext context, Camera camera)
{
this.context = context;
this.camera = camera;
}
}

然后我们在CustomRenderPipeline脚本中创建一个CameraRenderer实例,在进行渲染时遍历所有相机进行单独渲染。这种设计可以让每个相机使用不同的渲染方式绘制画面。

public class CustomRenderPipeline : RenderPipeline
{
CameraRenderer renderer = new CameraRenderer();
protected override void Render(ScriptableRenderContext context, Camera[] cameras)
{
foreach (Camera camera in cameras)
{
renderer.Render(context, camera);
}
}
}

1.2.2 绘制天空盒

接下来,我们可以让相机渲染一些东西了。定义一个DrawVisibleGeometry方法来绘制可见物。通过调用ScriptableRenderContext渲染接口的DrawSkybox()来绘制一个天空盒。但是到此还不行,因为通过context发送的渲染命令都是缓冲的,最后需要通过调用Submit()方法来正式提交渲染命令。

public void Render(ScriptableRenderContext context, Camera camera)
{
this.context = context;
this.camera = camera;

DrawVisibleGeometry();

Submit();
}

/// <summary>
/// 绘制可见物
/// </summary>
void DrawVisibleGeometry()
{
context.DrawSkybox(camera);
}
/// <summary>
/// 提交缓冲区渲染命令
/// </summary>
void Submit()
{
context.Submit();
}

这样天空盒就画出来了,Scene视图和Game视图都能看到,并且通过Frame Debugger也能看到天空盒的绘制Draw Call。

loading

但现在我们还无法控制相机,通过设置相机的Transform旋转发现毫无作用,Scene窗口右下角的Camera Preview视图也没有任何变化。因为我们还需要设置视图-投影变换矩阵,此转换矩阵结合了摄像机的位置和方向(视图矩阵)与摄像机的透视或正交投影(投影矩阵),Shader中这个属性叫unity_MatrixVP,是绘制几何图形时所用的Shader属性之一。在Frame Debugger可以选择一个Draw Call,在ShaderProperties中看到这个矩阵的属性。

loading

我们通过context.SetupCameraProperties方法来设置矩阵和相机的其他属性,把这一步封装在Setup方法中,渲染时放在绘制物体的前面调用。

public void Render(ScriptableRenderContext context, Camera camera)
{
this.context = context;
this.camera = camera;

Setup();
DrawVisibleGeometry();
Submit();
}
//设置相机的属性和矩阵
void Setup()
{
context.SetupCameraProperties(camera);
}

1.2.3 CommandBuffer

接下来介绍另一个渲染接口CommandBuffer。在内置渲染管线中,CommandBuffer就已经是控制Unity渲染流程的一种手段了。前面也说到,当执行context.Submit()提交缓冲区渲染命令才进行这一帧的渲染,某些任务,比如绘制天空盒,可以直接调用context的专用方法发出命令,而其它命令需要通过单独的命令缓冲区(CommandBuffer)间接发出,我们需要这样一个缓冲区来绘制场景中其它几何图形。CommandBuffer是一个容器,它保存了这些将要执行的渲染命令。

在CameraRender脚本中创建一个CommandBuffer实例来获得缓冲区,我们只需一个缓冲区即可,实例化时定义一个bufferName给缓冲区起个名字,用于在Frame Debugger中识别它。

  const string bufferName = "Render Camera";

CommandBuffer buffer = new CommandBuffer
{
name = bufferName
};

我们可以通过命令缓冲区的BeginSample和EndSample方法进行开启采样过程,这样在Profiler和Frame Debugger中就能进行显示,通常放在整个渲染过程的开始和结束,传参就用命令缓冲区的名字。执行缓冲区命令是通过context.ExecuteCommandBuffer(buffer)来执行,这个操作会从缓冲区复制命令但不会清除缓冲区,我们如果要重用buffer,一般会在执行完该命令后调用Clear()清除。通常执行命令和清除缓冲区是一起执行的,我们封装成一个ExecuteBuffer方法用来更方便地调用。

void Setup () 
{
buffer.BeginSample(bufferName);
ExecuteBuffer();
context.SetupCameraProperties(camera);
}
void Submit ()
{
buffer.EndSample(bufferName);
ExecuteBuffer();
context.Submit();
}
void ExecuteBuffer ()
{
context.ExecuteCommandBuffer(buffer);
buffer.Clear();
}

loading

1.2.4 清除渲染目标

把一个场景渲染出来的过程,最终就是把图像绘制到一个帧缓冲(FrameBuffer)上的过程。显示到屏幕上的每一帧的数据其实对应的就是内存中的数据,在内存中对应分配着存储帧数据的缓冲区,包括写入颜色的颜色缓冲(Color Buffer)、写入深度值的深度缓冲(Depth Buffer) 以及基于一些条件丢弃片元的模板缓冲(Stencil Buffer),最后还包括自定义的缓冲区,这几种缓冲一起称之为帧缓冲。

片元着色器在写入帧缓冲区之前会进行一系列的测试,例如模板测试、深度测试和Alpha测试等等,这些测试最终会决定当前像素是否需要写入帧缓冲中。

相机默认的渲染目标就是显示器的屏幕,屏幕是默认的FBO(帧缓冲对象),当然我们还可以让相机的渲染目标定义为RenderTexture(渲染纹理)来实现一些诸如后处理等效果。

为了保证下一帧绘制的图像正确,我们通常要清除渲染目标,清除旧的数据。该操作通过调用buffer.ClearRenderTarget方法来完成,该方法有三个参数,前两个参数用来设置是否需要清除深度数据和颜色数据,这里我们都设为true,第三个参数设置清除颜色数据的颜色,我们设置为Color.clear。我们在Setup()中一开始就调用它。

void Setup()
{
buffer.BeginSample(bufferName);
buffer.ClearRenderTarget(true, true, Color.clear);
ExecuteBuffer();
context.SetupCameraProperties(camera);
}

loading

这时我们打开帧调试器,发现多了一个Draw GL条目,用于相机的清除渲染目标操作。但会发现这样显示有一些问题,Render Camera样本条目变成了嵌套显示,这是因为ClearRenderTarget操作会自动包裹在一个使用命令缓冲区名字的样本条目中,而我们的BeginSample使用的也是命令缓冲区的名字,就会导致这种相同样本条目名字的嵌套问题。

我们可以在BeginSample之前清除渲染目标,使得两个相邻的同级渲染相机样本合并,这样在Frame Debugger的显示中就不会出现相同样本名嵌套。

另外,用于清除渲染目标的Draw GL条目是使用一个叫做Hidden/InternalClear的Shader绘制一个全屏的面片来写入渲染目标,但这不是清除渲染目标最快最有效的办法。我们对context.SetupCameraProperties的调用做一下调整,放在最开始阶段调用,在清除渲染目标之前就进行摄像机的属性设置,这样就能够实现快速清除。

void Setup()
{
context.SetupCameraProperties(camera);
buffer.ClearRenderTarget(true, true, Color.clear);
buffer.BeginSample(bufferName);
ExecuteBuffer();

}

loading

如上图所示,Draw GL条目已经变成了Clear(color+Z+stencil)条目,表示颜色、深度和模板缓冲区的旧数据都被清除了。

1.2.5 剔除(Culling)

我们只需要渲染在相机视野内的物体,视野外的物体需要剔除掉。这一步主要通过camera.TryGetCullingParameters方法得到需要进行剔除检查的所有物体,正式的剔除是通过context.Cull()实现的,最后会返回一个CullingResults的结构,里面存储了我们相机剔除后的所有视野内可见物体的数据信息。我们定义一个函数Cull来完成这个工作,然后在相机渲染Render()的最开始调用剔除操作。

     public void Render(ScriptableRenderContext context, Camera camera)
{
this.context = context;
this.camera = camera;

if (!Cull())
{
return;
}

Setup();
DrawVisibleGeometry();
Submit();
}

//存储剔除后的结果数据
CullingResults cullingResults;

/// <summary>
/// 剔除
/// </summary>
/// <returns></returns>
bool Cull()
{
ScriptableCullingParameters p;

if (camera.TryGetCullingParameters(out p))
{
cullingResults = context.Cull(ref p);
return true;
}
return false;
}

1.2.6 绘制几何体

当剔除裁剪完毕,我们就知道需要渲染哪些可见物体了。接下来就开始正式绘制,通过调用context.DrawRenderers方法来实现。它需要三个参数,除了上面的CullingResults,还需要一个DrawingSettings绘制设置和FilteringSettings,我们先用默认的设置,绘制物体的操作放在DrawVisibleGeometry()方法中的绘制天空盒之前完成。

    /// <summary>
/// 绘制几何体
/// </summary>
void DrawVisibleGeometry()
{
var drawingSettings = new DrawingSettings();
var filteringSettings = new FilteringSettings();
//图像绘制
context.DrawRenderers(
cullingResults, ref drawingSettings, ref filteringSettings
);

context.DrawSkybox(camera);
}

现在我们还是看不到有物体被绘制在屏幕上,因为我们还需要在DrawingSettings中设置是哪个Shader的哪个Pass进行渲染。在SRP中,旧的着色器大部分基本不能再使用,但没有光照的内置着色器Unlit被保留了下来,我们需要获取Pass名字为SRPDefaultUnlit的着色器标识ID,在最外部定义好后作为第一个参数传入DrawingSettings中。

我们还需要传入第二个参数,类型是SortSettings。创建该对象的时候把相机作为参数传入进来。该排序设置的作用是确定相机的透明排序模式是否使用正交或基于距离的排序。如果单单这样设置,就会发现绘制的顺序是没有规律的,我们通过设置排序的条件来让它有序地绘制物体。目前我们暂时使用不透明对象的典型排序模式SortingCriteria.CommonOpaque来设置。

最后我们还需要设置FilteringSettings,用于过滤给定的一组可见对象以便渲染,我们使用RenderQueueRange.all来渲染所有渲染队列内的对象。

static ShaderTagId unlitShaderTagId = new ShaderTagId("SRPDefaultUnlit");
...
void DrawVisibleGeometry ()
{
//设置绘制顺序和指定渲染相机
var sortingSettings = new SortingSettings(camera)
{
criteria = SortingCriteria.CommonOpaque
};
//设置渲染的Shader Pass和排序模式
var drawingSettings = new DrawingSettings(unlitShaderTagId, sortingSettings);
//设置哪些类型的渲染队列可以被绘制
var filteringSettings = new FilteringSettings(RenderQueueRange.all);
//图像绘制
context.DrawRenderers(
cullingResults, ref drawingSettings, ref filteringSettings
);
context.DrawSkybox(camera);
}

我们在场景中创建几个Cube,然后使用Shader为Unlit/Color的材质,颜色设置为蓝色,可以看到Cube终于被画在了屏幕中。

loading

1.2.7 透明和不透明几何分开绘制

现在我们创建2个球体,使用Unlit/Transparent shader的材质,发现并没有绘制在屏幕中。

loading

但是我们打开Frame Debugger查看,是有绘制的。

loading

因为在代码中天空盒是最后绘制的,这样会把透明物体给挡住。一般情况下,我们应当遵守 不透明物体->绘制天空盒->绘制透明物体 的绘制顺序。先绘制不透明物体,绘制天空盒的时候,经过深度测试,部分区域像素已经被不透明物体所占用,绘制天空盒的时候也就减少了绘制像素的数量,最后绘制透明物体,因为不会进行深度测试,所以可以通过颜色混合正确地绘制到屏幕上。

我们把 DrawVisibleGeometry()中的代码改造一下。首先将绘制不透明物体的过滤设置的渲染队列范围设置为opaque,然后在绘制天空盒之后重新设置排序设置为SortingCriteria.CommonTransparent,再将绘制不透明物体的过滤设置的渲染队列范围设置为transparent,最后再次调用DrawRenderers。

/// <summary>
/// 绘制几何体
/// </summary>
void DrawVisibleGeometry()
{
//设置绘制顺序和指定渲染相机
var sortingSettings = new SortingSettings(camera)
{
criteria = SortingCriteria.CommonOpaque
};
//设置渲染的Shader Pass和渲染排序
var drawingSettings = new DrawingSettings(unlitShaderTagId, sortingSettings);
////只绘制RenderQueue为opaque不透明的物体
var filteringSettings = new FilteringSettings(RenderQueueRange.opaque);
//1.绘制不透明物体
context.DrawRenderers(cullingResults, ref drawingSettings, ref filteringSettings);

//2.绘制天空盒
context.DrawSkybox(camera);

sortingSettings.criteria = SortingCriteria.CommonTransparent;
drawingSettings.sortingSettings = sortingSettings;
//只绘制RenderQueue为transparent透明的物体
filteringSettings.renderQueueRange = RenderQueueRange.transparent;
//3.绘制透明物体
context.DrawRenderers(cullingResults, ref drawingSettings, ref filteringSettings);

}

loading


1.3 编辑器渲染

这块主要是优化我们的代码结构,优化改进Unity编辑器的使用。

1.3.1 绘制SRP不支持的着色器类型

因为只有SRP支持Unlit类型的Shader Pass通过编译,我们的物体才能够渲染到屏幕中,所以在编辑器开发项目的过程中应该把那些不支持的着色器类型给暴露出来进行集中解决。特别是项目原来是用内置渲染管线开发的,然后想升级到SRP,那么这些不支持的Shader类型就应该暴露出来。

这里我们创建一个ShaderTagId的数组,把那些不支持的着色器类型标签给添加进来:

  //SRP不支持的着色器标签类型
static ShaderTagId[] legacyShaderTagIds =
{
new ShaderTagId("Always"),
new ShaderTagId("ForwardBase"),
new ShaderTagId("PrepassBase"),
new ShaderTagId("Vertex"),
new ShaderTagId("VertexLMRGBM"),
new ShaderTagId("VertexLM"),
};

创建DrawUnsupportedShaders方法绘制SRP不支持的着色器类型,在Render方法中绘制完所有几何体后调用:

public void Render(ScriptableRenderContext context, Camera camera)
{
//绘制几何体
DrawVisibleGeometry();
//绘制SRP不支持的着色器类型
DrawUnsupportedShaders();

Submit();
}

DrawUnsupportedShaders方法实现如下:

    /// <summary>
/// 绘制SRP不支持的着色器类型
/// </summary>
void DrawUnsupportedShaders()
{

//数组第一个元素用来构造DrawingSettings对象的时候设置
var drawingSettings = new DrawingSettings(legacyShaderTagIds[0], new SortingSettings(camera)) ;
for (int i = 1; i < legacyShaderTagIds.Length; i++)
{
//遍历数组逐个设置着色器的PassName,从i=1开始
drawingSettings.SetShaderPassName(i, legacyShaderTagIds[i]);
}
//使用默认设置即可,反正画出来的都是不支持的
var filteringSettings = FilteringSettings.defaultValue;
//绘制不支持的ShaderTag类型的物体
context.DrawRenderers(cullingResults, ref drawingSettings, ref filteringSettings);
}

代码的注释已经写得很清楚了,不再作其它解释。最后我们创建2个使用Standard材质的Cube,发现它们渲染到屏幕中是黑色的,因为我们的渲染管线没有给它设置所需的着色器属性。

loading

我们接下来使用Unity的ErrorShader来绘制不支持的着色器。先创建一个静态材质来缓存使用该Shader的材质,不需要每帧渲染时都new一个材质,然后在创建DrawSettings对象时设置绘制材质。

    //绘制成使用错误材质的粉红颜色
static Material errorMaterial;
/// <summary>
/// 绘制SRP不支持的内置Shader类型
/// </summary>
void DrawUnsupportedShaders()
{
//不支持的ShaderTag类型我们使用错误材质专用Shader来渲染(粉色颜色)
if (errorMaterial == null)
{
errorMaterial = new Material(Shader.Find("Hidden/InternalErrorShader"));
}

//数组第一个元素用来构造DrawingSettings的时候设置
var drawingSettings = new DrawingSettings(legacyShaderTagIds[0], new SortingSettings(camera))
{overrideMaterial = errorMaterial };
...
}

我们发现使用Standard Shader的Cube被绘制成了代表错误材质的粉色。

loading

1.3.2 动静代码分离:局部类

由于类似绘制不支持Shader对象等行为,在编辑器中开发时寻找问题有用,但是正式打包发布就没有作用了,同时也为了代码管理的漂亮一些,我们把只在Unity编辑器中使用的代码单独放在一个(partial)局部类中管理。局部类在很多项目的开发中比较常用,经常用于分离编辑器中静态编辑的相关代码和运行时动态调用的相关代码。

把CameraRenderer.cs脚本拷贝一份,重新命名为CameraRenderer.Editor.cs。两个脚本的类的定义前都加上partial关键字,这是一种组织代码的好办法,它们其实都是CameraRenderer这个类定义的一部分。

public partial class CameraRenderer { … }

在CameraRenderer.Editor脚本中,我们只保留渲染错误材质物体的字段和方法,并用#if UNITY_EDITOR宏包裹起来,意思为只在编辑器中代码生效:

partial class CameraRenderer 
{
#if UNITY_EDITOR
static ShaderTagId[] legacyShaderTagIds = { … };
static Material errorMaterial;
void DrawUnsupportedShaders () { … }
#endif
}

在CameraRenderer脚本中,把上面的渲染错误材质的字段和方法从脚本中删除,但是Render方法中的DrawUnsupportedShaders方法调用还是要保留。我们编译代码后发现会报错,因为在Render中我们一直在调用DrawUnsupportedShaders方法,但它却定义在Editor脚本中,并且是在加了UNITY_EDITOR的宏中定义的,所以我们在宏的外部还需要声明一下这个方法,类似抽象函数的声明,没有函数体,并且在声明方法前面也要加上partial关键字。在宏内的方法实体也要加上这个关键字:

    partial void DrawUnsupportedShaders ();
#if UNITY_EDITOR

partial void DrawUnsupportedShaders () { … }
#endif

1.3.3 绘制Gizmos

我们通过context.DrawGizmos()来绘制Gizmos辅助线框,它在工程的测试和编辑时是比较有用的,该方法放到Editor脚本中来定义实现。Handles.ShouldRenderGizmos决定是否绘制Gizmos。绘制时调用context.DrawGizmos方法,第一个传参是给定当前视图的相机,第二个传参是需要绘制的Gizmos子集,子集一共有两个,用于指定应在图像效果(后处理效果)之前还是之后绘制Gizmos。我们对两个子集都进行绘制。

partial void DrawGizmos();
#if UNITY_EDITOR
//绘制DrawGizmos
partial void DrawGizmos()
{
if (Handles.ShouldRenderGizmos())
{
context.DrawGizmos(camera, GizmoSubset.PreImageEffects);
context.DrawGizmos(camera, GizmoSubset.PostImageEffects);
}
}
#endif

然后在Render方法中绘制完所有可见物之后才绘制Gizmos。

public void Render (ScriptableRenderContext context, Camera camera) 
{


Setup();
DrawVisibleGeometry();
DrawUnsupportedShaders();
//绘制Gizmos
DrawGizmos();
Submit();
}

loading

1.3.4 绘制UI

当我们创建一个UGUI button的时候,会发现该UI在Game视图中显示,但在Scene视图是不显示的。通过Frame Debugger查看到UI是单独绘制的,而不是由我们的渲染管线绘制的。

loading

默认创建的画布为Render Mode的Screen Space - Overlay,如果我们改成Screen Space - Camera,并把RenderCamera属性设置成我们场景的主相机,再看Frame Debugger,就会发现UI也变成我们渲染透明物体的一部分了。

loading

但这个渲染顺序会有点问题,一般情况下我们会单独使用一个相机渲染UI,当绘制完所有可见物体后,最后绘制UI。这个我们先暂不处理,现在要做的是在Scene视图中把UI给绘制出来,在Editor脚本中通过定义PrepareForSceneWindow方法来实现。首先判断相机如果是在Scene视图渲染出来的,就调用ScriptableRenderContext.EmitWorldGeometryForSceneView方法将UI发送到Scene视图进行渲染。

partial void PrepareForSceneWindow ();
#if UNITY_EDITOR
...
/// <summary>
/// 在Game视图绘制的几何体也绘制到Scene视图中
/// </summary>
partial void PrepareForSceneWindow()
{
if (camera.cameraType == CameraType.SceneView)
{
//如果切换到了Scene视图,调用此方法完成绘制
ScriptableRenderContext.EmitWorldGeometryForSceneView(camera);
}
}
#endif

因为此操作可能会给Scene场景中添加一些几何体,所以我们在Render()方法中进行几何体剔除之前调用这个方法。

    public void Render(ScriptableRenderContext context, Camera camera)
{
this.context = context;
this.camera = camera;

// 在Game视图绘制的几何体也绘制到Scene视图中
PrepareForSceneWindow();

if (!Cull())
{
return;
}

...
}

loading


1.4 多摄像机

实际游戏中场景往往不止只有一个摄像机在进行绘制,所有我们需要对前面写的东西进行一些扩展,来支持多摄像机的正常渲染。

1.4.1 两个摄像机

游戏场景中的Main Camera深度值默认是-1,若场景中有多个相机,它们的渲染顺序是按深度递增渲染的。先新建一个普通相机,新建一个Tag叫做Secondary Camera,让新相机使用这个Tag,depth属性设为0,让它在Main Camera之后渲染。

loading

此时我们在帧调试发现两个相机渲染的内容都是一样的,因为中间清除了渲染目标,此时渲染的图像也是正确的。但是由于相邻的同级样本条目会被合并,所以我们发现只有一个Render Camera条目。为了区分两个相机渲染的条目,我们在CameraRenderer.Editor脚本中添加一个PrepareBuffer()方法,使用相机的名字去设置命令缓冲区的名字。最后在Render()方法的最开始就调用该方法。

partial void PrepareBuffer ();
#if UNITY_EDITOR
...
partial void PrepareBuffer ()
{
buffer.name = camera.name;
}
#endif

    public void Render(ScriptableRenderContext context, Camera camera)
{
this.context = context;
this.camera = camera;
//设置命令缓冲区的名字
PrepareBuffer();
...
}

如下图所示,通过不同的采样条目,我们可以方便地查看绘制信息,因为两个相机绘制的是相同的图像,所以Draw Call也是一样的。

loading

​我们还要解决一个问题,每次访问相机的name属性都会分配内存,每帧都去访问它是非常可怕的一件事。在编辑器模式下我们还不用太关心它,但在项目构建后在其它平台运行时我们要做好防护。在Editor脚本中,添加一个#else分支,如果是在编辑器下运行,定义一个SampleName属性,使用相机的名字给它和缓冲区的名字赋值。如果是在其它平台下运行,则SampleName只是作为一个常量字符串bufferName,也就是"Render Camera"。

#if UNITY_EDITOR
...
string SampleName { get; set; }

partial void PrepareBuffer ()
{
buffer.name = SampleName = camera.name;
}
#else
const string SampleName = bufferName;
#endif

我们在Setup和Submit方法中对采样过程使用SampleName这个属性。

    void Submit()
{
buffer.EndSample(SampleName);
...
}

void Setup()
{
...
buffer.BeginSample(SampleName);
ExecuteBuffer();

}

最后我们调整一下PrepareBuffer方法,使用Profiler.BeginSample("Editor Only")和Profiler.EndSample()将访问相机的名字并赋值的代码包裹起来,可以做到只在编辑器中分配内存,而不在构建项目后运行时分配内存。

using UnityEngine.Profiling;

#if UNITY_EDITOR
partial void PrepareBuffer()
{
//设置一下只有在编辑器模式下才分配内存
Profiler.BeginSample("Editor Only");
buffer.name = SampleName = camera.name;
Profiler.EndSample();
}
...
#endif

1.4.2 Culling Mask和Clear Flags

相机的Culling Mask默认为Everything,即渲染所有层级的可见物。若想剔除掉某些物体,可以把它们单独设置到一层Layer中,然后取消相机Culling Mask对该层的勾选。可以做个试验,将使用Standard Shader的物体的Layer都设置为Ignore Raycast,然后将Main Camera的Culling Mask取消对该层的勾选,而SecondCarema只勾选该层,最后的渲染结果如下图所示。

loading

我们发现只能看见Standard Shader的物体,这些物体由SecondCarema单独渲染它们。由于该相机的depth值比Main Camera高,且SecondCarema的Clear Flags是Skybox,这会使得SecondCarema在渲染前清除颜色缓冲区和深度缓冲区。然后使用天空盒填充一遍屏幕,所以第一个相机渲染的数据都被清除掉了,我们需要调整第二个相机的Clear Flags来结合两个相机的渲染结果。

首先在Setup方法中通过camera.clearFlags得到相机的CameraClearFlags对象。需要注意的是,这是一个枚举,枚举值从小到大分别是Skybox,Color,Depth和Nothing。最后一个值代表什么都不清除,其它三个都会清除深度缓冲区,所以这是一个清除量递减的枚举。

接下来我们修改下buffer.ClearRenderTarget()的传参。第一个参数代表是否要清除深度缓冲,我们设置为flags<=CameraClearFlags.Depth,因为前三个枚举都会清除深度缓冲。第二个参数代表是否要清除颜色缓冲,我们设置为flags==CameraClearFlags.Color,当相机的清除标志设置为Color时才清除颜色缓冲,当清除标志为Skybox的情况下,最终都会使用天空盒替换颜色缓冲的数据,所以我们无需设置。第三个参数设置用于清除缓冲区的颜色值,这里进行判断,如果我们设置的Clear Flags是Color,那么需要使用相机的Background属性的颜色值,由于我们使用的是线性色彩空间,颜色值进行一下转换,其它的Clear Flags还默认使用Color.clear(黑色)即可。

  void Setup()
{
context.SetupCameraProperties(camera);
//得到相机的clear flags
CameraClearFlags flags = camera.clearFlags;
//设置相机清除状态
buffer.ClearRenderTarget(flags <= CameraClearFlags.Depth, flags == CameraClearFlags.Color,
flags == CameraClearFlags.Color ? camera.backgroundColor.linear : Color.clear);
buffer.BeginSample(SampleName);
ExecuteBuffer();

}

接下来我们调整相机的Clear Flags属性来结合两个相机的渲染结果,由于Main Camera是最先进行渲染的,Clear Flags应为Solid Color或者Skybox,怎么结合渲染结果还是要看SecondCarema。我们可以设置成Depth Only或者Don't Clear。

如果设置成Depth Only,则深度缓冲区被清除,那么第二个相机渲染的物体有可能会挡住前面的相机渲染的物体,Main Camera渲染的图像就像一个背景图,这可能也不是我们想要的结果。所以我们设置成Don't Clear,保留颜色和深度缓冲区的数据,这样它们就像同一台相机渲染的一样。

loading

源代码及PDF课件地址:

文件下载
暂无评论发表评论